package com.github.thorqin.toolkit.store;

import com.github.thorqin.toolkit.service.IConfigurable;
import com.github.thorqin.toolkit.utility.*;
import com.google.common.base.Strings;
import org.w3c.dom.Element;

import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageTypeSpecifier;
import javax.imageio.ImageWriter;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.plugins.jpeg.JPEGImageWriteParam;
import javax.imageio.stream.ImageOutputStream;
import java.awt.image.BufferedImage;
import java.io.*;

import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.regex.Pattern;

public class FileStoreService implements IConfigurable {

	private String baseDir;

	@Override
	public void config(AppConfigManager configManager, String serviceName, boolean isReConfig) {
		String rootDir = configManager.get(serviceName, String.class, "files");
		if (Strings.isNullOrEmpty(rootDir)) {
			throw new RuntimeException("Invalid file store path setting!");
		}
		baseDir = configManager.getApplication().getDataDir(rootDir);
	}

	public static  class FileBasicInfo {
		public String fileId;
		public long createTime;
		public long modifyTime;
		public long length = 0;
		public String sha256;
	}

	public static class FileInfo extends FileBasicInfo {
		public String fileName;
		public String mimeType;
		public String hash;
		public Map<String, Object> extra = new HashMap<>();

		public String getName() {
			if (fileName.contains(".")) {
				return fileName.substring(0, fileName.lastIndexOf("."));
			} else {
				return fileName;
			}
		}
		public String getExtName() {
			if (fileName.contains(".")) {
				return fileName.substring(fileName.lastIndexOf(".") + 1);
			} else {
				return "";
			}
		}

		public void set(String key, Object value) {
			if (this.extra == null) {
				this.extra = new HashMap<>();
			}
			this.extra.put(key, value);
		}

		public Object get(String key) {
			if (this.extra != null) {
				return this.extra.get(key);
			} else {
				return null;
			}
		}

	}

	public byte[] getSHA256(FileBasicInfo info) {
		if (info.sha256 != null) {
			return Encryptor.hexData(info.sha256);
		} else {
			return Encryptor.sha256(this.getDataFile(info));
		}
	}

	public String getRootDir() {
		return baseDir;
	}

	public File getDataFile(FileBasicInfo info) {
		if (info.sha256 != null) {
			return new File(hashToPath(info.sha256) + ".data");
		} else {
			return new File(fileIdToPath(info.fileId) + ".data");
		}
	}

	public File getDataFile(String fileId) throws IOException {
		return getDataFile(getFileInfo(fileId));
	}


	public File getJsonFile(FileBasicInfo info) {
		return new File(fileIdToPath(info.fileId) + ".json");
	}

	public FileInfo createFileInfo(String fileName) throws IOException {
		return createFileInfo(fileName, null);
	}

	public FileInfo createFileInfo(String fileName, String mimeType) throws IOException {
		FileInfo info = new FileInfo();
		info.fileId = UUID.randomUUID().toString().replaceAll("-", "");
		info.fileName = fileName;
		info.mimeType = (mimeType == null ? MimeUtils.getFileMime(info.getExtName()) : mimeType);
		info.createTime = new Date().getTime();
		// Make sure all parent folders are exist.
		String filePath = fileIdToPath(info.fileId);
		File dir = new File(filePath).getParentFile();
		Files.createDirectories(dir.toPath());
		return info;
	}

	public boolean saveFileInfo(FileInfo info, boolean createNew) throws IOException {
		String filePath = fileIdToPath(info.fileId);
		File dir = new File(filePath).getParentFile();
		Files.createDirectories(dir.toPath());
		File jsonFile = getJsonFile(info);
		if (createNew && !jsonFile.createNewFile()) {
			return false;
		}
		Serializer.writeJsonFile(info, jsonFile, true);
		return true;
	}

	private String fileIdToPath(String fileId) {
		StringBuilder sb = new StringBuilder(baseDir.length() + 50);
		sb.append(baseDir).append("/");
		int len = fileId.length(), i = 0, scan = 0;
		while (i < 5 && scan + 2 < len) {
			sb.append(fileId, scan, scan + 2);
			sb.append('/');
			i++;
			scan += 2;
		}
		sb.append(fileId);
		return sb.toString();
	}

	public String hashToPath(String sha256) {
		StringBuilder sb = new StringBuilder(baseDir.length() + 50);
		sb.append(baseDir).append("/store/");
		int len = sha256.length(), i = 0, scan = 0;
		while (i < 5 && scan + 3 < len) {
			sb.append(sha256, scan, scan + 3);
			sb.append('/');
			i++;
			scan += 3;
		}
		sb.append(sha256);
		return sb.toString();
	}

	private String filePathToId(File file) {
		String path = file.getAbsoluteFile().getPath();
		String basePath = new File(baseDir).getAbsoluteFile().getPath();
		if (!path.startsWith(basePath)) {
			return null;
		}
		if (!path.endsWith(".json")) {
			return null;
		}
		String name = file.getName();
		return name.substring(0, name.length() - 5);
	}

	public void deleteFile(String fileId) {
		String filePath = fileIdToPath(fileId);
		File jsonFile = new File(filePath + ".json");
		if (jsonFile.exists()) {
			jsonFile.delete();
		}
		File dataFile = new File(filePath + ".data");
		if (dataFile.exists()) {
			dataFile.delete();
		}
	}
	public void moveToDir(String fileId, String destDir) throws IOException {
		moveToDir(fileId, destDir, true);
	}
	public void moveToDir(String fileId, String destDir, boolean replaceExisting) throws IOException {
		String name = getFileInfo(fileId).fileName;
		moveToDir(fileId, destDir, name, replaceExisting);
	}
	public void moveToDir(String fileId, String destDir, String newName) throws IOException {
		moveToDir(fileId, destDir, newName, true);
	}
	public void moveToDir(String fileId, String destDir, String newName, boolean replaceExisting) throws IOException {
		File fileDir = new File(destDir);
		Files.createDirectories(fileDir.toPath());
		File path = new File(fileDir.toString() + "/" + newName);
		moveTo(fileId, path, replaceExisting);
	}
	public void moveTo(String fileId, File destPath) throws IOException {
		moveTo(fileId, destPath, true);
	}
	public void moveTo(String fileId, File destPath, boolean replaceExisting) throws IOException {
		copyTo(fileId, destPath, replaceExisting);
		deleteFile(fileId);
	}

	public void copyToDir(String fileId, String destDir) throws IOException {
		copyToDir(fileId, destDir, true);
	}
	public void copyToDir(String fileId, String destDir, boolean replaceExisting) throws IOException {
		String name = getFileInfo(fileId).fileName;
		copyToDir(fileId, destDir, name, replaceExisting);
	}
	public void copyToDir(String fileId, String destDir, String newName) throws IOException {
		copyToDir(fileId, destDir, newName, true);
	}
	public void copyToDir(String fileId, String destDir, String newName, boolean replaceExisting) throws IOException {
		File fileDir = new File(destDir);
		Files.createDirectories(fileDir.toPath());
		File path = new File(fileDir.toString() + "/" + newName);
		copyTo(fileId, path, replaceExisting);
	}
	public void copyTo(String fileId, File destPath) throws IOException {
		copyTo(fileId, destPath, true);
	}
	public void copyTo(String fileId, File destPath, boolean replaceExisting) throws IOException {
		FileInfo info = getFileInfo(fileId);
		String filePath;
		if (info.sha256 != null) {
			filePath = hashToPath(info.sha256);
		} else {
			filePath = fileIdToPath(fileId);
		}
		File dataFile = new File(filePath + ".data");
		File parentFile = destPath.getParentFile();
		if (parentFile != null)
			Files.createDirectories(parentFile.toPath());
		if (replaceExisting)
			Files.copy(dataFile.toPath(), destPath.toPath(), StandardCopyOption.REPLACE_EXISTING);
		else
			Files.copy(dataFile.toPath(), destPath.toPath());
	}

	public FileInfo store(File originFile) throws IOException, NoSuchAlgorithmException {
		return store(originFile, originFile.getName(), null);
	}

	public FileInfo store(File originFile, String mimeType) throws IOException, NoSuchAlgorithmException {
		try (InputStream in = new FileInputStream(originFile)) {
			return store(in, originFile.getName(), mimeType);
		}
	}

	public FileInfo store(File originFile, String fileName, String mimeType) throws IOException, NoSuchAlgorithmException {
		try (InputStream in = new FileInputStream(originFile)) {
			return store(in, fileName == null ? originFile.getName() : fileName, mimeType);
		}
	}

	public FileInfo store(byte[] data, String fileName) throws IOException, NoSuchAlgorithmException {
		return store(data, fileName, null);
	}

	public FileInfo store(byte[] data, String fileName, String mimeType) throws IOException, NoSuchAlgorithmException {
		try (InputStream in = new ByteArrayInputStream(data)) {
			return store(in, fileName, mimeType);
		}
	}

	public FileInfo store(InputStream in, String fileName) throws IOException, NoSuchAlgorithmException {
		return store(in, fileName, null);
	}

	public FileInfo store(InputStream in, String fileName, String mimeType) throws IOException, NoSuchAlgorithmException {
		FileInfo info = new FileInfo();
		info.fileId = UUID.randomUUID().toString().replaceAll("-", "");
		info.fileName = fileName;
		info.mimeType = (mimeType == null ? MimeUtils.getFileMime(info.getExtName()) : mimeType);
		info.createTime = new Date().getTime();
		String filePath = fileIdToPath(info.fileId);
		String dataFile = filePath + ".data";
		File f = new File(dataFile);
		File dir = f.getParentFile();
		Files.createDirectories(dir.toPath());
		try (BufferedOutputStream bos = new BufferedOutputStream(
			new FileOutputStream(f))) {
			int length;
			byte[] buffer = new byte[4096];
			long sum = 0;
			MessageDigest sha265Digest = MessageDigest.getInstance("SHA-256");
			while ((length = in.read(buffer)) != -1) {
				bos.write(buffer, 0, length);
				sha265Digest.update(buffer, 0, length);
				sum += length;
			}
			info.length = sum;
			info.modifyTime = f.lastModified();
			info.createTime = info.modifyTime;
			info.hash = Encryptor.hexString(sha265Digest.digest());
		}
		store(info);
		return info;
	}

	public FileInfo store(BufferedImage image, String fileName) throws IOException {
		return store(image, fileName, null);
	}

	public FileInfo store(BufferedImage image, String fileName, Float jpegCompression) throws IOException {
		FileInfo info = new FileInfo();
		info.fileId = UUID.randomUUID().toString().replaceAll("-", "");
		info.fileName = fileName;
		String mimeType;
		String imageType;
		if (info.getExtName().isEmpty() || info.getExtName().toLowerCase().equals("png")) {
			mimeType = "image/png";
			imageType = "png";
		} else if (info.getExtName().matches("(?i)jpe?g")) {
			mimeType = "image/jpeg";
			imageType = "jpeg";
		} else
			throw new RuntimeException("Unsupported image format!");
		info.mimeType = mimeType;
		String filePath = fileIdToPath(info.fileId);
		String dataFile = filePath + ".data";
		// Make sure all parent folders are exist.
		File f = new File(dataFile);
		File dir = f.getParentFile();
		Files.createDirectories(dir.toPath());
		try (FileOutputStream bos = new FileOutputStream(f)) {
			if (imageType.equals("jpeg")) {
				saveAsJPEG(image, bos, jpegCompression);
				//ImageIO.write(image, imageType, bos);
			} else
				ImageIO.write(image, imageType, bos);
		}
		info.length = f.length();
		info.modifyTime = f.lastModified();
		info.createTime = info.modifyTime;
		info.hash = Encryptor.sha256String(f);
		store(info);
		return info;
	}

	public void store(FileInfo info) throws IOException {
		if (info.sha256 == null) {
			File dataFile = getDataFile(info);
			if (dataFile.isFile()) {
				info.length = dataFile.length();
				info.sha256 = Encryptor.sha256String(dataFile);
				info.hash = info.sha256;
				File destFile = getDataFile(info);
				if (!destFile.isFile()) {
					try {
						Files.createDirectories(destFile.getParentFile().toPath());
						Files.move(dataFile.toPath(), destFile.toPath());
					} catch (FileAlreadyExistsException e) {
						// do nothing
						System.out.println("Files.move() failed: already exists.");
					}
				}
				if (dataFile.exists()) {
					dataFile.delete();
				}
			} else {
				info.hash = null;
				info.length = 0;
			}
		} else {
			info.hash = info.sha256;
			File dataFile = getDataFile(info);
			if (!dataFile.isFile()) {
				throw new IOException("Data file not found in hash store!");
			}
			info.length = dataFile.length();
		}
		Serializer.writeJsonFile(info, getJsonFile(info), true);
	}

	public static void saveAsJPEG(BufferedImage image, OutputStream os, Float jpegCompression) throws IOException {
		saveAsJPEG(image, os, jpegCompression, null);
	}
	public static void saveAsJPEG(BufferedImage image, OutputStream os) throws IOException {
		saveAsJPEG(image, os, 0.7f, null);
	}
	public static void saveAsJPEG(BufferedImage image, OutputStream os, Float jpegCompression, Integer dpi) throws IOException {
		ImageWriter imageWriter  =  ImageIO.getImageWritersBySuffix("jpg").next();
		try (ImageOutputStream ios  =  ImageIO.createImageOutputStream(os)) {
			imageWriter.setOutput(ios);
			//and metadata
			IIOMetadata imageMetaData = imageWriter.getDefaultImageMetadata(new ImageTypeSpecifier(image), null);
			if (dpi != null && dpi > 0) {
				Element tree = (Element) imageMetaData.getAsTree("javax_imageio_jpeg_image_1.0");
				Element jfif = (Element) tree.getElementsByTagName("app0JFIF").item(0);
				jfif.setAttribute("Xdensity", Integer.toString(dpi));
				jfif.setAttribute("Ydensity", Integer.toString(dpi));
			}
			JPEGImageWriteParam jpegParams = null;
			if (jpegCompression != null && jpegCompression >= 0 && jpegCompression <= 1f) {
				// Compression
				jpegParams = (JPEGImageWriteParam) imageWriter.getDefaultWriteParam();
				jpegParams.setCompressionMode(JPEGImageWriteParam.MODE_EXPLICIT);
				jpegParams.setCompressionQuality(jpegCompression);
			}
			//Write and clean up
			imageWriter.write(imageMetaData, new IIOImage(image, null, null), jpegParams);
		} finally {
			imageWriter.dispose();
		}
	}

	private static long getCreationTime(File f) throws IOException {
		BasicFileAttributes attributes = Files.readAttributes(f.toPath(), BasicFileAttributes.class);
		return attributes.creationTime().toMillis();
	}

	private static Pattern ID_FOLDER_PATTERN = Pattern.compile("^[0-9A-Fa-f]{2}$");

	private void listFiles(File folder, List<FileBasicInfo> files) throws IOException {
		File[] subFiles = folder.listFiles();
		if (subFiles == null)
			return;
		for (File f: subFiles) {
			if (f.isDirectory() && ID_FOLDER_PATTERN.matcher(f.getName()).matches()) {
				listFiles(f, files);
			} else if (f.isFile() && f.getName().matches(".+\\.json$")) {
				FileBasicInfo info = new FileBasicInfo();
				info.fileId = filePathToId(f);
				info.createTime = getCreationTime(f);
				info.modifyTime = f.lastModified();
				info.length = f.length();
				files.add(info);
			}
		}
	}

	public List<FileBasicInfo> listFiles() throws IOException {
		File dir = new File(baseDir);
		List<FileBasicInfo> files = new LinkedList<>();
		listFiles(dir, files);
		return files;
	}

	/**
	 * Delete child folder if it's empty.
	 * @param folder Folder file
	 * @return Return true if folder is empty
	 */
	private static boolean emptyFolder(File folder) {
		boolean isEmpty = true;
		File[] files = folder.listFiles();
		if (files == null)
			return true;
		for (File f: files) {
			if (f.isFile())
				isEmpty = false;
			else if (f.isDirectory()) {
				if (emptyFolder(f))
					f.delete();
				else
					isEmpty = false;
			}
		}
		return isEmpty;
	}

	/**
	 * Keep upload directory clean, delete any empty child folders.
	 */
	public void deleteEmptyFolder() {
		File dir = new File(baseDir);
		emptyFolder(dir);
	}

	/**
	 * Delete old files which if currentTime - file.createTime &gt; timeInterval
	 * @param expiredMinutes in minute
	 * @throws java.io.IOException When cannot delete files
	 */
	public void deleteExpired(long expiredMinutes) throws IOException {
		List<FileBasicInfo> list = listFiles();
		long now = new Date().getTime();
		for (FileBasicInfo info: list) {
			if (now - info.createTime > expiredMinutes * 60 * 1000)
				deleteFile(info.fileId);
		}
	}

	public FileInfo getFileInfo(String fileId) throws IOException {
		String filePath = fileIdToPath(fileId);
		File jsonFile = new File(filePath + ".json");
		if (jsonFile.exists()) {
			FileInfo fileInfo = Serializer.readJsonFile(jsonFile, FileInfo.class);
			fileInfo.modifyTime = jsonFile.lastModified();
			File dataFile = getDataFile(fileInfo);
			if (dataFile != null && dataFile.isFile()) {
				fileInfo.length = dataFile.length();
			}
			return fileInfo;
		} else {
			return null;
		}
	}

	public String getFileIndexPath(String fileId) {
		String filePath = fileIdToPath(fileId);
		File file = new File(filePath);
		return file.getParentFile().toString();
	}
}
